模块与包
通常,Lua不会设置规则(policy)。相反,Lua会提供许多强有力的机制来使开发者有能力实现出最适合的规则。然而,这种方法对于模块就不可行了。模块系统的一个主要目标是允许以不同的形式来共享代码。但若没有一项公共的规则就无法实现这样的共享。
Lua从5.1开始,为模块和包(package
)定义了一系列的规则。这些规则不需要语言引入额外的技能,程序员可以使用他们早已熟知的table
、函数、元表和环境来实现这些规则。然而,有两个重要的函数可以很容易通过这些规则,它们是require
(用于使用模块)和module
(用于创建模块)。程序员完全可以使用不同的规则来重新实现这两个函数。但是,新的实现可能会使程序无法使用外部模块,或者编写的模块无法被外部程序所使用。
从用户观点来看,一个模块就是一个程序库,可以通过require
来加载。然后便得到了一个全局变量,表示一个table
。这个table
就像是一个名称空间,其内容就是模块中导出的所有东西,例如函数和常量。一个规范的模块还应使require
返回这个table
。
使用table来实现模块的优点在于,可以像操作普通table那样来操作模块,并且能利用Lua现有的功能来实现各种额外的功能。在大多数语言中,模块不是“第一类值(first-class value)
”,所以那些语言需要为模块实现一套专门的机制。在Lua中,可以轻易地实现所有这些功能。
例如,一个用户要调用一个模块中的函数。其中最简单的方法是:
require "mod"
mod.foo()
如果希望使用较短的模块名称,则可以为模块设置一个局部名称:
local m = require "mod"
m.foo()
还可以为个别函数提供不同的名称:
require "mod"
local f = mod.foo
f()
上述这些方法,都不需要来自于语言的显式支持,只需使用语言现有的内容。
require函数
Lua提供了一个名为require
的高层函数用来加载模块,但这个函数只假设了关于模块的基本概念。对于require
而言,一个模块就是一段定义了一些值的代码。
要加载一个模块,只需简单地调用require "<模块名>"
。该调用会返回一个由模块函数组成的table
,并且还会定义一个包含该table
的全局变量。然而,这些行为都是由模块完成的,而非require
。所以,有些模块会选择返回其他值,或者具有其他的效果。
即使知道某些用到的模块可能已加载了,但只要用到require
就是一个良好的编程习惯。可以将标准库排除在此规则之外,因为Lua总是会预先加载它们。不过,有些用户还是喜欢为标准库中的模块使用显式的require
:
local m = require "io"
m.write("hello world\n")
以下代码详细说明了require
的行为:
function require(name)
if not packag.loaded[name] then -- 模块是否已加载?
local loader = findloader(name)
if loader == nil then
error("unable to load module " .. name)
end
package.loaded[name] = true -- 将模块标记为已加载
local res = loader(name) -- 初始化模块
if res ~= nil then
package.loaded[name] = res
end
end
return package.loaded[name]
end
首先,它在table package.loaded
中检查模块是否已加载。如果是的话,require
就返回相应的值。因此,只要一个模块已加载过,后续的require
调用都将返回同一个值,不会再次加载它。
如果模块尚未加载,require
就试着为该模块找一个加载器(loader
),会先在table package.preload
中查询传入的模块名。如果在其中找到了一个函数,就以该函数作为模块的加载器。通过这个preload table
,就有了一种通用的方法来处理各种不同的情况。通常这个table
中不会找到有关指定模块的条目,那么require
就会尝试从Lua文件或C程序库中加载模块。
如果require
为指定模块找到了一个Lua文件,它就通过loadfile
来加载该文件。而如果找到的是一个C程序库,就通过loadlib
来加载。注意,loadfile
和loadlib
都只是加载了代码,并没有运行它们。为了运行代码,require
会以模块名作为参数来调用这些代码。如果加载器有返回值,require
就将这个返回值存储到table package.loaded
中,以此作为将来对同一模块调用的返回值。如果加载器没有返回值,require
就会返回table package.loaded
中的值。在本章后面会看到,一个模块还可以将返回给require
的值直接放入package.loaded
中。
上述代码中还有一个重要的细节,就是在调用加载器前,require
先将true
赋予了package.loaded
中的对应字段,以此将模块标记为已加载。这是因为如果一个模块要求加载另一个模块,而后者又要递归地加载前者。那么后者的require
调用就会马上返回,从而避免了无限循环。
若要强制使require
对用一个库加载两次的话,可以简单地删除package.loaded
中的模块条目。例如,在成功地require "foo"
后,package.loaded["foo"]
就不为nil
了。下面代码就可以再次加载该模块:
package.loaded["foo"] = nil
require "foo"
在搜索一个文件时,require
所使用的路径与传统的路径有所不同。大部分程序所使用的路径就是一连串目录,指定了某个文件的具体位置。然而,ANSIC却没有任何关于目录的概念。所以,require
采用的路径是一连串的模式(pattern
),其中每项都是一种将模块名转换为文件名的方式。进一步说,这种路径中的每项都是一个文件名,每项中还包含一个可选的问号。require
会用模块名来替换每个“?”,然后根据替换的结果来检查是否存在这样一个文件。如果不存在,就会尝试下一项。路径中的每项以分号隔开。例如,假设路径为:
?;?.lua;c:\windows\?;/usr/local/lua/?/?.lua
那么,调用require "sql"
就会试着打开以下文件:
sql
sql.lua
c:\windows\sql
/usr/local/lua/sql/sql.lua
require
函数只处理了分号(作为各项之间的分隔符)和问号。其他例如目录分隔符或文件扩展名,都由路径自己定义。
require
用于搜索Lua文件的路径存放在变量package.path
中。当Lua启动后,便以环境变量LUA_PATH
的值来初始化这个变量。如果没有找到该环境变量,则使用一个编译时定义的默认路径来初始化。在使用LUA_PATH
时,Lua会将其中所有的子串“;;”替换成默认路径。例如,假设LUA_PATH
为“mydir/?.lua;;
”,那么最终路径就是“mydir/?.lua
”,并紧随默认路径。
如果require
无法找到与模块名相符的Lua文件,它就会找C程序库。这类搜索会从变量package.cpath
(相对于package.path
)获取路径。而这个变量则是通过环境变量LUA_CPATH
(相对于LUA_PATH
)来初始化。在UNIX中,它的值一般是这样的:
./?.so;/usr/local/lib/lua/5.1/?.so
注意,文件的扩展名是由路径定义的(例如,上例中使用的.so)。而在Windows中,此路径通常可以是这样的:
.\?.dll;C:\Program Files\Lua501\dll\?.dll
当找到一个C程序库后,require
就会通过package.loadlib
来加载它,loadlib
在前面章节中已讨论过。C程序库与Lua程序块是不同的,它没有定义一个单一的主函数,而是导出了几个C函数。具有良好行为的C程序库应该导出一个名为“luaopen_<模块名>
”的函数。require
会在链接完程序库后,尝试调用这个函数。将在后续章节中讨论如何编写C程序库。
一般通过模块的名称来使用它们。但有时必须将一个模块改名,以避免冲突。一种典型的情况是,在测试中需要加载同一模块的不同版本。对于一个Lua模块来说,其内部名称不是固定的,可以轻易地编辑它以改变其名称。但是却无法编辑一个二进制数据模块中luaopen_*函数的名称
。为了允许这种重命名,require
用到了一个小技巧:如果一个模块名中包含了连字符,require
就会用连字符后的内容来创建luaopen_*函数名
。例如,若一个模块名为a-b
,require
就认为它的open
函数名为luaopen_b
,而不是luaopen_a-b
。因此,如果要使用的两个模块名都为mod
,那么可以将其中一个重命名为v1-mod
(或者-mod
,或其他类似形式)。当调用m1 = require "v1-mod"
时,require
会找到改名后的文件v1-mod
,并将其中的函数luaopen_mod
作为open
函数。
编写模块的基本方法
在Lua中创建一个模块最简单的方法是:创建一个table
,并将所有需要导出的函数放入其中,最后返回这个table
。以下代码演示这种方法。注意,将inv
声明为程序块的局部变量,就是将其定义成一个私有的名称。
complex = {}
function complex.new(r, i) return {r=r, i=i} end
-- 定义一个常量'i'
complex.i = complex.new(0, 1)
function complex.add(c1, c2)
return complex.new(c1.r c2.r, c1.i + c2.i)
end
function complex.sub(c1, c2)
return complex.new(c1.r - c2.r, c1.i - c2.i)
end
function complex.mul(c1, c2)
return complex.new(c1.r*c2.r - c1.i*c2.i,
c1.r*c2.i + c1.i*c2.r)
end
local function inv(c)
local n = c.r^2 + c.i^2
return complex.new(c.r/n, -c.i/n)
end
function complex.div(c1, c2)
return complex.mul(c1, inv(c2))
end
return complex
上例中使用table
编写模块时,没有提供与真正模块完全一致的功能性,首先,必须显式地将模块名放到每个函数定义中。其次,一个函数在调用同一模块中的另一个函数时,必须限定被调用函数的名称。可以使用一个固定的局部名称(例如M
)来定义和调用模块内的函数,然后将这个局部名称赋予模块的最终名称。通过这种方法,可以将上例改写为:
local M = {}
complex = M -- 模块名
M.i = {r=0, i=1}
function M.new(r, i) return {r=r, i=i} end
function M.add(c1, c2)
return M.new(c1.r + c2.r, c1.i + c2.i)
end
<如前>
只要一个函数调用了同一模块中另一个函数(或者递归地调用自己),就仍需要一个前缀名称。但至少两个函数之间的连接不再需要依赖模块名,并且也只需在整个模块中的一处写出模块名。实际上,可以完全避免写模块名,因为require
会将模块名作为参数传给模块:
local modname = ...
local M = {}
_G[modname] = M
M.i = {r=0, i=1}
<如前>
经过这样的修改,若需要重命名一个模块,只需重命名并定义它的文件就可以了。
另一项小改进与结尾的return
语句有关。若能将所有与模块相关的设置任务集中在模块开头,会更好。消除return
语句的一种方法是,将模块table
直接赋予package.loaded
:
local modname = ...
local M = {}
_G[modname] = M
package.loaded[modname] = M
<如前>
通过这样的赋值,就不需要在模块结尾返回M
了。注意,如果一个模块无返回值的话,require
就会返回package.loaded[modname]
的当前值。
使用环境
创建模块的基本方法的缺点在于,它要求程序员投入一些额外的关注。当访问同一模块中的其他公共实体时,必须限定其名称。并且,只要一个函数的状态从私有改为公有(或从公有改为私有),就必须修改调用。另外,在私有声明中也很容易忘记关键字local
。
“函数环境”是一种有趣的技术,它能够解决所有上述创建模块时遇到的问题。基本想法就是让模块的主程序块有一个独占的环境。这样不仅它的所有函数都可共享这个table
,而且它的所有全局变量也都记录在这个table
中。还可以将所有公有函数声明为全局变量,这样它们就都自动地记录在一个独立的table
中了。模块所要做的就是将这个table
赋予模块名和package.loaded
。以下代码片段演示了这种技术:
local modname = ...
local M = {}
_G[modname] = M
package.loaded[modname] = M
setfenv(1, M)
此时,当声明函数add
时,它就称为了complex.add
:
function add(c1, c2)
return new(c1.r + c2.r, c1.i + c2.i)
end
此外,在调用同一模块的其他函数时,也不再需要前缀。例如,add
会从其环境中得到new
,也就是complex.new
。
这种方法为模块提供了一种良好的支持,并且只引入了一点额外的工作。此时完全不需要前缀,并且调用一个导出的函数与调用一个私有函数没有什么区别。如果程序员忘记了写local关键字,那么也不会污染全局名称空间。只会将一个私有函数变成了公有而已。
还缺少什么?是的,那就是访问其他模块。当创建了一个空table M
作为环境后,就无法访问前一个环境中全局变量了。以下提出几种重获访问的方法,每种方法各有其优缺点。
最简单的方法是继承,就像之前看到的那样:
local modname = ...
local M = {}
_G[modname] = M
package.loaded[modname] = M
setmetatable(M, {__index = _G})
setfenv(1, M)
必须先调用setmetatable
再调用setfenv
,因为通过这种方法,模块就能直接访问任何全局标识了,每次访问只需付出很小的开销。这种方法导致了一个后果,即从概念上说,此时的模块中包含了所有的全局变量。例如,某人可以通过你的模块来调用标准的正弦函数:complex.math.sin(x)
。
还有一种更便捷的方法来访问其他模块,即声明一个局部变量,用以保存对旧环境的访问:
local modname = ...
local M = {}
_G[modname] = M
package.loaded[modname] = M
local _G = _G
setfenv(1, M)
此时必须在所有全局变量的名称前加“_G.
”。由于没有涉及到元方法,这种访问会比前面的方法略快。
一种更正规的方法是将那些需要用到的函数或模块声明为局部变量:
-- 模块设置
local modname = ...
local M = {}
_G[modname] = M
package.loaded[modname] = M
-- 导入段:
-- 声明这个模块从外界所需的所有东西
local sqrt = math.sqrt
local io = io
-- 在这句之后就不再需要外部访问了
setfenv(1, M)
这种技术要求做更多的工作,但是它能清晰地说明模块的依赖性。同时,较之前面的两种方法,它的运行速度也更快。
module函数
读者或许注意到了,前面几个示例中的代码形式。它们都以相同的模式开始:
local modname = ...
local M = {}
_G[modname] = M
package.loaded[modname] = M
<setup for external access>
setfenv(1, M)
Lua5.1提供了一个新函数module
,它囊括了以上这些功能。在开始编写一个模块时,可以直接用以下代码来取代前面的设置代码:
module(...)
这句调用会创建一个新的table
,并将其赋予适当的全局变量和loaded table
,最后还会将这个table
设为主程序块的环境。
默认情况下,module
不提供外部访问。必须在调用它前,为需要访问的外部函数或模块声明适当的局部变量。也可以通过继承来实现外部访问,只需在调用module
时加一个选项package.seeall
。这个选项等价于以下代码:
setmetatable(M, {__index = _G})
因而只需这么做:
module(..., package.seeall)
在一个模块文件的开头有了这句调用后,后续所有的代码都可以像普通的Lua代码那样编写了。不需要限定模块名和外部名字,同样也不需要返回模块table
。要做的只是加上这么一句调用。
module
函数还提供了一些额外的功能。虽然大部分模块不需要这些功能,但有些发行模块可能需要一些特殊处理(例如,一个模块中同时包含C函数和Lua函数)。module
在创建模块table
之前,会先检查package.loaded
是否已包含了这个模块,或者是否已存在与模块同名的变量。如果module
由此找到了这个table
,它就会复用该table
作为模块。也就是说,可以用module
来打开一个已创建的模块。如果没有找到模块table
,module
就会创建一个模块table
。然后在这个table
中设置一些预定义的变量,包括:_M
,包含了模块table
自身(类似于_G
);_NAME
,包含了模块名(传给module
的第一个参数);_PACKAGE
,包含了包(package
)的名称。
子模块与包
Lua支持具有层级性的模块名,可以用一个点来分隔名称中的层级。假设,一个模块名为mod.sub
,那么它就是mod
的一个子模块。因此,可以认为模块mod.sub
会将其所有值都定义在table mod.sub
中,也就是一个存储在table mod
中且key
为sub
的table
。一个“包(Package)”就是一个完整的模块树,它是Lua中发行的单位。
当require
一个模块mod.sub
时,require
会用原始的模块名“mod.sub
”作为key
来查询table package.loaded
和package.preload
,其中,模块名中的点在搜索中没有任何含义。
然而,当搜索一个定义子模块的文件时,require
会将点转换为另一个字符,通常就是系统的目录分隔符。转换之后require
就像搜索其他名称一样来搜索这个名称。例如,假设路径为:
./?.lua;/usr/local/lua/?.lua;/usr/local/lua/?/init.lua
并且目录分隔符为“/
”,那么调用require "a.b"
就会尝试打开以下文件:
./a/b.lua
/usr/local/lua/a/b.lua
/usr/local/lua/a/b/init.lua
通过这样的加载策略,就可以将一个包中的所有模块组织到一个目录中。例如,一个包中有模块p
、p.a
和p.b
,那么它们对应的文件名就分别为p/init.lua
,p/a.lua
和p/b.lua
,它们都是目录p
下的文件。
Lua使用的目录分隔符是编译时配置的,可以是任意的字符串。例如,在没有目录层级的系统中,就可以使用“_
”作为“目录分隔符”。那么require "a.b"
就会搜索到文件a_b.lua
。
C函数名中不能包含点,因此一个用C编写的子模块a.b
无法导出函数luaopen_a.b
。所以,require
会将点转换为下划线。例如,一个名为a.b
的C程序库就应将其初始化函数命名为luaopen_a_b
。在此又可以巧用连字符,来实现一些特殊的效果。例如,有一个C程序库a
,现在想将它作为mod
的一个子模块,那么就可以将文件名改为mod/-a
。当执行require "mod.-a"
时,require
就会找到改名后的文件mod/-a
及其中的函数luaopen_a
。
作为一项扩展功能,require
在加载C子模块时还有一些选项。当require
加载子模块时,无法找到对应的Lua文件或C程序库。它就会再次搜索C路径,不过这次将以包的名称来查找。例如,一个程序require
子模块a.b.c
,当无法找到文件a/b/c
时,再次搜索就会找到文件a
。如果找到了C程序库a
,require
就查看该程序库中是否有open
函数luaopen_a_b_c
。这项功能使得一个发行包可以将几个子模块组织到一个单一C程序库中,并且具有各自的open
函数。
module
函数也为子模块提供了显式的支持。当我们创建一个子模块时,调用module ("a.b.c")
,module
就会将环境table
放入变量a.b.c
,也就是“table a中的table b中的table c
”。如果这些中间的table
不存在,module
就会创建它们。否则,就复用它们。
从Lua的观点看,同一个包中的子模块除了它们的环境table
是嵌套的之外,它们之间并没有显式的关联性。require
模块a
并不会自动地加载它的任何子模块。同样,require
子模块a.b
也并不会自动地加载a
。当然只要愿意,包的实现者完全可以实现这种关联。例如,模块a
的一个子模块在加载时会显式地加载a
。
🔚